<?php
/**
 * This file is part of the Sphoof framework.
 * Copyright (c) 2010-2011 Sphoof
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code. You can also view the
 * LICENSE file online at http://www.sphoof.nl/new-bsd.txt
 *
 * @category	Sphoof
 * @copyright	Copyright (c) 2010-2011 Sphoof (http://sphoof.nl)
 * @license		http://sphoof.nl/new-bsd.txt	New BSD License
 * @package		Configuration
 *
 * @todo		Make it possible to conveniently combine config files or objects
 *
 */

/**
 * Config class
 *
 * Provides a way to load and manage custom configuration values for different contexts
 *
 * Example contexts would be: development, debug and production
 * @package Configuration
 */
class SpConfig {
	/**
	 * Holds a multi-dimensional array with configuration key-value pairs
	 *
	 * @var array
	 */
	protected $contexts = array(
		'default' => array(
			'values' => array( )
		)
	);

	/**
	 * Holds the current context, for example 'production', 'debug' or 'development'
	 * Is always cast to lowercase
	 *
	 * @var string
	 */
	protected $currentContext = 'default';

	/**
	 * Sets an array of values to a specific context or to the current contexts
	 * if $context is omitted
	 *
	 * @param array $values
	 * @param string $context
	 * @return SpConfig
	 */
	public function setValues( array $values, $context = null ) {
		foreach ( $values as $key => $value ) {
			$this->setValue( $key, $value, $context );
		}
		return $this;
	}

	/**
	 * Set a single key-value pair to a specific context or to the current
	 * context if $context is omitted
	 *
	 * @param string $key
	 * @param string $value
	 * @param string $context
	 * @return SpConfig
	 */
	public function setValue( $key, $value, $context = null ) {
		if ( is_array($value) ) {
			foreach ( $value as $subkey => $subvalue ) {
				$this->setValue( $key . '.' . $subkey, $subvalue, $context );
			}
		}
		if ( !isset($context) ) {
			$context = $this->currentContext;
		}
		if ( !isset($this->contexts[$context]) ) {
			throw new SpException( 'Context ' . $context . ' not found.' );
		}
		$branch = &$this->contexts[$context]['values'];
		foreach ( explode( '.', $key ) as $part ) {
			if ( !isset( $branch[$part] ) ) {
				$branch[$part] = array();
			}
			$branch = &$branch[$part];
		}
		$branch = $value;
		return $this;
	}

	/**
	 * Changes the context of the configuration. If the context parameter is
	 * omitted, leave it the same.
	 *
	 * @param string $newcontext
	 * @return string The context after changing
	 */
	public function setContext( $newcontext = null ) {
		if ( isset($newcontext ) ) {
			if ( !isset($this->contexts[$newcontext]) ) {
				throw new SpException('Unknown context '.$newcontext);
			}
			$this->currentContext = $newcontext;
		}
		return $this->currentContext;
	}

	/**
	 * Adds a new context to the scope. The context is cast to a lowercase string.
	 * Optionally, you can directly provide an context to copy the values from.
	 *
	 * @param string $contextname A name for the context
	 * @param string $parent A context to get the undefined values from
	 * @return SpConfig
	 */
	public function newContext( $contextname, $parent = 'default' ) {
		if ( isset($this->contexts[$contextname]) ) {
			throw new SpException('Context '.$contextname.' cannot be created, because it already exists.');
		}
		if ( isset($parent) and !isset($this->contexts[$parent]) ) {
			throw new SpException('Context '.$parent.' does not exist.');
		}
		$this->contexts[$contextname] = array(
			'values' => array()
		);
		if ( isset($parent) ) {
			$this->contexts[$contextname]['parent'] = $parent;
		}
		return $this;
	}

	/**
	 * Retrieve a configuration value from the current context
	 * When the key is not found and the $default is not set, it throws an exception
	 *
	 * @param string $key
	 * @param string $default
	 * @param string $context
	 * @return string
	 */
	public function get( $key, $default = null ) {
		$context = $this->currentContext;
		$values = array( );
		if ( null !== ( $value = $this->getFromContext( $key, $context ) ) ) {
			$values[] = $value;
		}
		while ( isset( $this->contexts[$context]['parent'] ) ) {
			$context = $this->contexts[$context]['parent'];
			if ( null !== ( $value = $this->getFromContext( $key, $context ) ) ) {
				$values[] = $value;
			}
		}
		if ( count( $values ) == 0 ) {
			if ( isset($default) ) {
				return $default;
			}
			throw new SpException('Configuration for '.$key.' not found in context '.$this->currentContext);
		}
		if ( !is_array( $values[0] ) ) {
			return $values[0];
		}
		$aggregrated = array( );
		while ( $value = array_pop( $values ) ) {
			if ( is_array( $value ) ) {
				$aggregrated = $this->combineArrays( $aggregrated, $value );
			}
		}
		return $aggregrated;
	}

	/**
	 * Retrieve a value from a targeted context, without inheriting or default
	 * values.
	 *
	 * @param string $key
	 * @param array $context
	 * @return mixed
	 */
	protected function getFromContext( $key, $context ) {
		if ( !$this->validPath( $key, $this->contexts[$context]['values'] ) ) {
			return null;
		}
		$branch = $this->contexts[$context]['values'];
		foreach ( explode( '.', $key ) as $part ) {
			$branch = $branch[$part];
		}
		return $branch;
	}

	/**
	 * When combining arrays, array values may overwrite scalar values, scalar
	 * values may overwrite arrays values, but if both are arrays, we should
	 * merge them instead of letting one array replace the other.
	 *
	 * @param array $default
	 * @param array $overwrite
	 */
	protected function combineArrays( $default, $overwrite ) {
		$result = $overwrite + $default;
		foreach ( $overwrite as $key => $value ) {
			if ( is_array( $value )
				and isset( $default[$key] )
				and is_array( $default[$key] ) ) {
				$result[$key] = $this->combineArrays( $default[$key], $value );
			}
		}
		return $result;
	}

	/**
	 * Traverse the tree $branch for a leaf described by $path
	 * 
	 * For example
	 * $path = 'foo.bar.all'
	 * $branch = array(
	 *   'foo' => array(
	 *     'baz' => 'none',
	 *     'bar' => array(
	 *       'all' => 'yes'
	 *     )
	 *   )
	 * )
	 * Here, true would be returned
	 *
	 * @param string $path
	 * @param array $branch
	 * @return boolean
	 */
	protected function validPath( $path, $branch ) {
		foreach ( explode( '.', $path ) as $part ) {
			if ( !isset( $branch[$part] ) ) {
				return false;
			}
			$branch = $branch[$part];
		}
		return true;
	}

}

/**
 * SpIniConfig
 *
 * An adaptation of the SpConfig class, which provides an easy way to parse ini files into
 * a config object.
 *
 * If no sections are provided in the ini file, all is loaded in context 'default'
 * If sections are provided in the ini file, the contexts are named after the section
 * If section are named like [foo: bar], the foo context takes the bar values as default
 *
 * @todo When you have an array in the default part of the ini file, it will be interpreted as
 *		a context part. This could be resolved by forbidding a default part, some additional
 *		preg_matching of the file, or plainly documenting not to use an array outside of sections.
 *
 * @package Configuration
 *
 */
class SpIniConfig extends SpConfig {

	/**
	 * Constructor loads the given ini file
	 *
	 * @param string $filename The filename of the ini file
	 */
	public function __construct( $filename ) {
		$info = parse_ini_file( $filename, true );
		foreach ( $info as $key => $value ) {
			if ( is_array( $value ) ) {
				$parts = explode( ':', $key );
				$context = trim( $parts[0] );
				if ( count($parts) == 1 ) {
					$this->newContext( $context, 'default' );
				}
				else {
					$this->newContext( $context, trim( $parts[1] ) );
				}
				$this->setValues( $value, $context );
			}
			else {
				$this->setValue( $key, $value, 'default' );
			}
		}
	}

}